SPB Git

Repository Public

page.tsx 12.0 KB 296 lines tsx

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// Page programmatique par municipalité : stats, répartition par type,3// fiches en HTML (maillage vers /estimation/[id]) et municipalités voisines.4import type { Metadata } from "next";5import Link from "next/link";6import { notFound } from "next/navigation";7import {8  munBySlug,9  munStats,10  neighborMunicipalities,11  sampleUnits,12} from "@/lib/municipalites";13import {14  EST_YEAR,15  SITE_URL,16  TYPE_LABELS,17  TYPE_SLUGS,18  moneyFr,19  numFr,20} from "@/lib/seo";21import { VENTE_TYPE_LABELS, dateFrLong, ventesParMunicipalite, vsRolePct } from "@/lib/ventes";2223export const dynamic = "force-dynamic";2425export async function generateMetadata({26  params,27}: {28  params: Promise<{ slug: string }>;29}): Promise<Metadata> {30  const { slug } = await params;31  const mun = munBySlug(slug);32  if (!mun) return { title: "Municipalité introuvable" };33  const s = munStats(mun.nom)!;34  const title = `Valeur des propriétés à ${mun.nom} — médiane ${moneyFr(s.mediane2026)} (${EST_YEAR})`;35  const description = `${numFr(s.n)} propriétés évaluées à ${mun.nom} : valeur médiane estimée ${moneyFr(36    s.mediane202637  )}, croissance ${s.croissancePct == null ? "—" : `${s.croissancePct.toFixed(1).replace(".", ",")} %`} depuis 2021. Maisons, plex, condos, chalets et terrains — estimation gratuite par adresse.`;38  return {39    title,40    description,41    alternates: { canonical: `/municipalite/${slug}` },42    openGraph: { title, description, type: "website" },43  };44}4546export default async function MunicipalitePage({47  params,48}: {49  params: Promise<{ slug: string }>;50}) {51  const { slug } = await params;52  const mun = munBySlug(slug);53  if (!mun) notFound();54  const s = munStats(mun.nom)!;55  const samples = sampleUnits(mun.nom, null, 24);56  const voisines = neighborMunicipalities(mun.nom, 10);57  const ventes = ventesParMunicipalite(mun.nom, 12);58  const url = `${SITE_URL}/municipalite/${slug}`;5960  const jsonLd = {61    "@context": "https://schema.org",62    "@graph": [63      {64        "@type": "City",65        "@id": `${url}#place`,66        name: mun.nom,67        url,68        geo: { "@type": "GeoCoordinates", latitude: mun.lat, longitude: mun.lng },69        containedInPlace: { "@type": "AdministrativeArea", name: "Québec, Canada" },70      },71      {72        "@type": "BreadcrumbList",73        itemListElement: [74          { "@type": "ListItem", position: 1, name: "Accueil", item: SITE_URL },75          { "@type": "ListItem", position: 2, name: "Municipalités", item: `${SITE_URL}/municipalites` },76          { "@type": "ListItem", position: 3, name: mun.nom, item: url },77        ],78      },79    ],80  };8182  return (83    <div className="pb-6 pt-10 sm:pt-14">84      <script85        type="application/ld+json"86        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}87      />88      <nav className="vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-2">89        <Link href="/" className="hover:underline">90          Accueil91        </Link>{" "}92        /{" "}93        <Link href="/municipalites" className="hover:underline">94          Municipalités95        </Link>{" "}96        / <span className="text-ink">{mun.nom}</span>97      </nav>9899      <section className="mt-6">100        <span className="kicker">Municipalité · Québec</span>101        <h1 className="vp-display mt-3 max-w-4xl text-[clamp(28px,5.4vw,56px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">102          L&apos;immobilier à <span className="hl">{mun.nom}</span>103        </h1>104        <p className="mt-4 max-w-2xl text-[15.5px] text-ink-2">105          {numFr(s.n)} unités d&apos;évaluation au rôle foncier {EST_YEAR},106          estimées par le modèle Vrai-Prix (modèle hédonique + ventes réelles).107          Cherchez n&apos;importe quelle adresse de {mun.nom} depuis{" "}108          <Link href="/" className="underline">109            l&apos;accueil110          </Link>{" "}111          ou explorez{" "}112          <Link href="/carte" className="underline">113            la carte114          </Link>115          .116        </p>117      </section>118119      {/* ---- Tuiles statistiques ---- */}120      <section className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">121        {[122          ["Propriétés évaluées", numFr(s.n)],123          [`Valeur médiane ${EST_YEAR}`, moneyFr(s.mediane2026)],124          [`Valeur totale ${EST_YEAR}`, moneyFr(s.total2026)],125          [126            "Croissance 2021 → 2026",127            s.croissancePct == null128              ? "—"129              : `${s.croissancePct >= 0 ? "+" : ""}${s.croissancePct.toFixed(1).replace(".", ",")} %`,130          ],131        ].map(([label, val]) => (132          <div key={label} className="vp-card p-4">133            <p className="klabel">{label}</p>134            <p className="vp-display mt-1 break-words text-[clamp(17px,2.2vw,26px)] font-bold tracking-[-0.02em]">135              {val}136            </p>137          </div>138        ))}139      </section>140141      {/* ---- Répartition par type ---- */}142      <section className="mt-12">143        <span className="kicker">Par type de propriété</span>144        <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">145          Médianes par type à {mun.nom}146        </h2>147        <div className="mt-5 overflow-x-auto">148          <table className="w-full min-w-[560px] border-collapse text-[14px]">149            <thead>150              <tr className="border-b-2 border-ink text-left">151                <th className="klabel py-2 pr-4">Type</th>152                <th className="klabel py-2 pr-4 text-right">Propriétés</th>153                <th className="klabel py-2 pr-4 text-right">Médiane {EST_YEAR}</th>154                <th className="klabel py-2 text-right">Valeur totale</th>155              </tr>156            </thead>157            <tbody>158              {s.parType.map((t) => (159                <tr key={t.type} className="border-b border-[rgba(20,24,20,0.12)]">160                  <td className="py-2.5 pr-4">161                    <Link162                      href={`/municipalite/${slug}/${TYPE_SLUGS[t.type] ?? t.type}`}163                      className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"164                    >165                      {TYPE_LABELS[t.type]?.pluriel ?? t.type}166                    </Link>167                  </td>168                  <td className="vp-mono py-2.5 pr-4 text-right">{numFr(t.n)}</td>169                  <td className="vp-mono py-2.5 pr-4 text-right">170                    {moneyFr(t.mediane2026)}171                  </td>172                  <td className="vp-mono py-2.5 text-right">{moneyFr(t.total2026)}</td>173                </tr>174              ))}175            </tbody>176          </table>177        </div>178      </section>179180      {/* ---- Propriétés les plus valorisées (liens indexables) ---- */}181      {samples.length > 0 && (182        <section className="mt-12">183          <span className="kicker">Aperçu du registre</span>184          <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">185            Propriétés les plus valorisées à {mun.nom}186          </h2>187          <ul className="mt-5 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">188            {samples.map((u) => (189              <li key={u.id_provinc}>190                <Link191                  href={`/estimation/${encodeURIComponent(u.id_provinc)}`}192                  className="group flex items-baseline justify-between gap-3 border-b border-[rgba(20,24,20,0.12)] py-1.5 text-[13.5px]"193                >194                  <span className="truncate group-hover:underline">195                    {u.adresse}196                    {u.apt ? `, app. ${u.apt}` : ""}197                  </span>198                  <span className="vp-mono shrink-0 text-[11px] text-ink-2">199                    {moneyFr(u.est_2026)}200                  </span>201                </Link>202              </li>203            ))}204          </ul>205          <p className="mt-4 text-[13px] text-ink-2">206            Chaque adresse de {mun.nom} a sa fiche : estimation {EST_YEAR},207            fourchette P10-P90, historique 2021-{EST_YEAR} et comparables réels.208          </p>209        </section>210      )}211212      {/* ---- Dernières ventes réelles (registre /ventes) ---- */}213      {ventes.rows.length > 0 && (214        <section className="mt-12">215          <span className="kicker">Ventes réelles</span>216          <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">217            Dernières ventes à {mun.nom}218          </h2>219          <p className="mt-2 text-[13.5px] text-ink-2">220            {numFr(ventes.n365)} ventes publiées sur 12 mois221            {ventes.median365 != null ? ` · prix médian ${moneyFr(ventes.median365)}` : ""}.222            Transactions captées chaque jour et appariées au rôle foncier.223          </p>224          <div className="mt-4 overflow-x-auto">225            <table className="w-full min-w-[620px] border-collapse text-[14px]">226              <thead>227                <tr className="border-b-2 border-ink text-left">228                  <th className="klabel py-2 pr-4">Date</th>229                  <th className="klabel py-2 pr-4">Adresse</th>230                  <th className="klabel py-2 pr-4">Type</th>231                  <th className="klabel py-2 pr-4 text-right">Prix de vente</th>232                  <th className="klabel py-2 text-right">vs rôle</th>233                </tr>234              </thead>235              <tbody>236                {ventes.rows.map((v) => {237                  const p = vsRolePct(v.amount, v.valeur_role);238                  return (239                    <tr key={v.id} className="border-b border-[rgba(20,24,20,0.12)]">240                      <td className="vp-mono whitespace-nowrap py-2.5 pr-4 text-[12px] text-ink-2">{dateFrLong(v.date)}</td>241                      <td className="py-2.5 pr-4">242                        {v.id_provinc ? (243                          <Link244                            href={`/estimation/${encodeURIComponent(v.id_provinc)}`}245                            className="font-medium underline decoration-[rgba(20,24,20,0.3)] underline-offset-2 hover:decoration-ink"246                          >247                            {v.street ?? "Adresse non publiée"}248                          </Link>249                        ) : (250                          <span className="font-medium">{v.street ?? "Adresse non publiée"}</span>251                        )}252                      </td>253                      <td className="py-2.5 pr-4 text-ink-2">{VENTE_TYPE_LABELS[v.property_type ?? ""] ?? v.property_type ?? "—"}</td>254                      <td className="vp-display py-2.5 pr-4 text-right text-[15px] font-bold tracking-[-0.01em]">{moneyFr(v.amount)}</td>255                      <td className={`vp-mono py-2.5 text-right ${p != null && p < 0 ? "text-[var(--danger)]" : "text-ink-2"}`}>256                        {p == null ? "—" : `${p >= 0 ? "+" : "−"}${Math.abs(p).toFixed(0)} %`}257                      </td>258                    </tr>259                  );260                })}261              </tbody>262            </table>263          </div>264          <p className="mt-4">265            <Link href={`/ventes?q=${encodeURIComponent(mun.nom)}&periode=365`} className="btn btn-ghost">266              Toutes les ventes à {mun.nom} →267            </Link>268          </p>269        </section>270      )}271272      {/* ---- Municipalités voisines ---- */}273      {voisines.length > 0 && (274        <section className="mt-12">275          <span className="kicker">À proximité</span>276          <h2 className="vp-display mt-3 text-[clamp(20px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">277            Municipalités voisines278          </h2>279          <ul className="mt-4 flex flex-wrap gap-2">280            {voisines.map((v) => (281              <li key={v.slug}>282                <Link283                  href={`/municipalite/${v.slug}`}284                  className="stat-chip transition-colors hover:text-accent-deep"285                >286                  {v.nom}287                </Link>288              </li>289            ))}290          </ul>291        </section>292      )}293    </div>294  );295}296
Files main
Menu

Theme